Skip to content

fix(runtime): release native objects on the runtime's thread - #560

Draft
edusperoni wants to merge 3 commits into
mainfrom
fix/release-on-runtime-thread
Draft

edusperoni wants to merge 3 commits into
mainfrom
fix/release-on-runtime-thread

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Current behavior

JS can run on any thread that holds the isolate's Locker: a JS function passed to dispatch_async on a background queue runs there, for example. A garbage collection triggered on that thread runs the wrappers' finalizers on it too, and they release the native objects inline. A view, a CATransaction, or a block capturing one can therefore be deallocated off the main thread, which UIKit and Core Animation do not tolerate and which can crash the app. __releaseNativeCounterpart called from such a thread has the same problem.

New behavior

Which releases move. A native release moves to the runtime's thread when two things hold:

  • it happens off that thread;
  • the object first reached JS on that thread.

ObjCDataWrapper and BlockWrapper record the second at construction. Each runtime records its own thread (pthread_self(), next to its run loop) where it binds its event loop, and the check compares it with the calling thread through the isolate's runtime. Several runtimes can share a thread.

Which stay put.

  • Objects that first reached JS on another thread are still released where the collection runs. A background queue's own objects are not moved onto the main thread.
  • Collection adapters still release inline, because their -dealloc retires the wrapper.

How the handover works.

  • The release ([object release] or Block_release, from a finalizer or __releaseNativeCounterpart) goes into a per-isolate queue, drained by one event loop task per batch.
  • JS-side disposal is unchanged and still immediate: the wrapper is retired, the Instances entry erased and the internal field cleared at once.
  • The queue is plain containers with no mutex or atomics. Finalizers, __releaseNativeCounterpart, wrapper creation, the drain task and teardown all hold the isolate's Locker. The only lock left is the event loop's own, once per batch.
  • Teardown drains the queue inline before and after the disposal walk, since the event loop has stopped by then.

Re-wrapping a queued object. A new wrapper for an object whose release is still queued takes that release over as its own retain instead of retaining again. That keeps exact the retain counts the JS-extended classes' retain/release swizzle reads. If native code retained the object in the meantime, the new wrapper is protected on the spot, since the swizzle had no wrapper to protect then.

Approximations.

  • "First reached JS" is a stand-in for "created on": an object created on main but first handed to JS by background code counts as background-born. Touching UIKit objects from a background thread is already outside UIKit's rules.
  • A worker's runtime thread is the worker's own thread, not the main thread.

On the runtime's thread nothing changes except a thread-id compare per ObjC or block wrapper creation and disposal.

Documented in docs/knowledge/v8-resurrecting-finalizers.md ("Which thread releases the native object").

Testing

New specs in GCFinalizerTests.js. Both use a JS-extended TNSApi instance owned only by its wrapper, which records NSThread.isMainThread from methodCalledInDealloc; __collect() runs in a dispatch_async block on a background queue.

  • Created on main: the dealloc runs on main. With the deferral disabled this spec fails (Expected $[0] = false to equal true).
  • Created inside the background block: the dealloc stays on the background thread.

Full suite, 0 failures (1869 specs).

JS can run on any thread that holds the isolate's Locker, for example a
JS function dispatched to a background queue. A garbage collection there
runs the wrappers' finalizers on that thread, and they released the
native objects inline, so a view, a CA transaction or a block capturing
one could be deallocated off the main thread and crash.

Off the runtime's thread, ObjectManager now hands these releases
([object release] and Block_release, from finalizers and from
__releaseNativeCounterpart) to the runtime's run loop. JS-side disposal
is unchanged and still immediate. The hand-over is a CFRunLoop block
holding a per-isolate queue, not an event loop task, so it still runs
after the event loop stops taking work, and teardown drains inline.

A new wrapper for an object whose release is still waiting takes that
release over as its own retain instead of retaining again, so the
retain-count thresholds that protect JS-extended instances stay right;
if native code retained the object meanwhile, the new wrapper is
protected on the spot. Collection adapters still release inline: their
-dealloc retires the wrapper.

A worker's runtime thread is the worker's own thread, so objects
released by a worker isolate are still released there.
@coderabbitai

coderabbitai Bot commented Nov 12, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

…read

Deferring every off-thread release moved all of a background queue's
deallocations onto the main thread, including objects that never
belonged there. Wrappers now record whether the object first reached JS
on its runtime's thread (a thread-local marker the runtime sets where it
binds its event loop, compared with the current isolate when the wrapper
is built), and only those releases are handed over. Objects first
wrapped on another thread are released where the collection runs, as
before.

The queue is now plain per-isolate containers: finalizers,
__releaseNativeCounterpart, wrapper creation, the drain and teardown all
hold the isolate's Locker, so it needs no mutex or atomics. It drains in
one event loop task per batch instead of a CFRunLoop block; teardown,
which runs after the loop stops, drains it inline before and after the
disposal walk.
A single thread-local marker named one isolate per thread, so a second
runtime started on the same thread replaced it, and either one's
teardown cleared it for both. Each runtime now records its thread where
it binds its event loop, and the check compares it with the calling
thread through the isolate's runtime.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant